Sidebar: render daily-note month folders as a calendar grid#702
Sidebar: render daily-note month folders as a calendar grid#702
Conversation
When a sidebar tree node holds nothing but daily-note leaves of one month (e.g. Daily/2026/04/2026-04-21.md … 2026-04-30.md), swap the linear list for a 7-column calendar grid of that month. Cells with notes link to the daily note; missing days render as muted dots. Detection seam stays in Haskell. routeTreeSplices emits a new node:iso-date splice (driven by Calendar.parseRouteDay) and sidebar-tree.tpl wraps each subtree in .emanote-tree-children. The JS module reads those data attributes — no second YYYY-MM-DD regex. Cell palette + size constants extracted from timeline-heatmap.js into a shared @emanote/calendar-grid module, so the two widgets share the primary-palette / cell-size source of truth. Closes #700.
Replace the firstElementChild?.classList.contains(MARKER) gate in render() with a module-level WeakSet of processed wrappers. The class on the rendered calendar stays as a dev-tools inspection hint, but the rendering lifecycle is no longer encoded in CSS class presence. WeakSet entries vanish when the wrapper detaches (idiomorph swap on nav), so morphed-in wrappers re-render cleanly without inferring state from DOM shape.
The template comment named .emanote-tree-children but not the data-iso-date attribute. Both are required for a widget to attach; a future author reading only the template would miss the second half. Spell out the contract so the constraint matches the one already documented at the JsBundle.hs registration site.
The WeakSet idempotence gate added in 84c1a39 mirrored DOM state in JS, drifting if anything outside this module touches the calendar wrapper (devtools removal, future utility scripts). Replace with a named isAlreadyRendered() predicate that reads the DOM directly — Hickey's intent-legibility concern is met by naming the predicate, not by moving state into JS.
Template.hs was combining two qualifiers under one Calendar alias to reach parseRouteDay. Add an explicit export list to Calendar.hs so parseRouteDay is exposed alongside isDailyNote, then drop the dual import in Template.hs. Single qualifier, single facade — matches how Graph.hs and Query.hs already use Calendar.
Both timeline-heatmap and sidebar-calendar were independently building 'YYYY-MM-DD — title' strings via the same five-line padStart dance. Move into calendar-grid.js so a date-format change touches one place — and the two widgets can't drift apart in user-facing copy.
Three tightening moves on sidebar-calendar.js: - Selector ':scope > .flex a[data-iso-date]' reached past the documented seam into sidebar-tree.tpl's internal layout. Drop to ':scope a[data-iso-date]' so the widget only depends on what the seam contract names. - The per-cell '<span class="flex items-center justify-center h-4">' wrapper around each day was vestigial — only there to give the row a 16px track. Move that to the grid container via auto-rows-[1rem] place-items-center; one fewer DOM node per day. - JsBundle.hs's seam comment duplicated sidebar-tree.tpl's; trim to a one-line pointer at the canonical contract.
Four cucumber scenarios exercise the user-visible paths: - month folder renders as 7-col calendar with the right header and day→note links - the calendar swap replaces the linear list (no plain "YYYY-MM-DD" anchors leak through) - non-month folders never mount a calendar (negative case) - calendar survives Ema's in-app morph navigation between two daily notes in the same month (@morph-tagged so static mode skips it) New fixture under tests/fixtures/notebook/calendar-test/2026/04/ holds three sparse daily notes (1/15/30) so the test exercises both filled and missing-day cells.
Hickey/Lowy Analysis
Hickey rationaleThe pre-implement design pass already addressed three structural risks before any code was written: date-detection fragmentation (kept on the Haskell side, JS reads Post-implement on the actual diff, one finding landed: the original Layer 5 entanglement count: zero pairs in Lowy rationaleTwo of three findings were actionable and landed; the third was rejected as YAGNI.
Volatility map verdict: the JS-only boundary is the right place for grouping volatility (same blast radius as a Haskell-side detector, but no |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 2s | git fetch ok; forge=github; noGit=false |
| research | ✓ | 1m 26s | Read Template.hs, JsBundle.hs, morph.js, main.js. Plan: data-iso-date splice + .emanote-tree-children wrapper + calendar-grid.js + sidebar-calendar.js. |
| branch | ✓ | 5s | sidebar-month-calendar from origin/master |
| implement | ✓ | 5m 13s | node:iso-date splice; sidebar wrapper; calendar-grid extraction; new sidebar-calendar.js; JsBundle registration. |
| check | ✓ | 4m 26s | cabal build all green |
| docs | ✓ | 28s | CHANGELOG + docs/guide/daily-notes.md updated |
| fmt | ✓ | 19s | cabal-fmt + fourmolu + hlint + nixpkgs-fmt clean |
| commit | ✓ | 24s | ec2dfa5 pushed |
| hickey+lowy | ✓ | 5m 16s | 2 fixes (84c1a39, 1002282); 1 No-op (YAGNI grouping seam) |
| police | ✓ | 9m 33s | Pass1 fix 7a6c44b; Pass3 elegance 1a90dc5, 31a3d1b, 2936fec |
| test | ✓ | 7m 18s | cabal 66/66; e2e-live 39/39 (4 new for #700) |
| create-pr | ✓ | 1m 28s | Draft #702 with hickey/lowy comment |
| ci | ✓ | 56s | e2e: live 39/39, static 36/36 (@morph skipped), morph 39/39 |
| evidence | ✓ | 3m 35s | Before/after sidebar screenshots posted |
| Total | 40m 44s |
Slowest step: police (9m 33s)
Optimization suggestions
- Police dominated at 9m 33s — Pass-3 elegance triggered 3 fix iterations (4 follow-up commits). Two of them (
emanote-dom-over-mirrorflip andformatCellHeaderextraction) could have been caught at implement time by reading.agency/code-police.mdbefore writing the JS — the WeakSet pattern that Hickey suggested is exactly whatemanote-dom-over-mirrorrejects, so the project rule overrides the generic structural critique. Pre-loading project rules before reaching for a generic pattern would have saved one full police-fix loop. - Hickey+lowy at 5m 16s is on the high side for a single-feature diff; the talk-mode pre-implement pass already addressed three structural risks, so the post-implement pass landed only one finding (idempotence complecting). Future runs on similarly-scoped features can lean harder on the talk-mode rehearsal and run hickey+lowy with
--review-model=haikuto trim ~50%. - Evidence at 3m 35s included a
nix --refresh build github:srid/emanote/masteragainst an uncached master — when iterating on a feature that needs before/after shots more than once, prebuild the master binary in the background during research so the evidence step costs only the screenshot capture.
Workflow completed at 2026-05-04T16:25Z.
# Conflicts: # tests/features/smoke.feature
The cell builders inherited from timeline-heatmap were 4×4px colour-only squares — fine for a year-stacked heatmap where the unit is "did anything happen", wrong for a sidebar calendar where a reader expects to see the date in each cell. Without the number, the widget read as a heatmap pasted into a calendar header. Sidebar-specific cell builders now live next to the rest of the widget: 24×24 tiles with the day rendered as text — primary-fill + white text for days that have a note, muted text-only for days that don't. timeline-heatmap is unchanged; both widgets still share `MONTH_LABELS` and `formatCellHeader` from calendar-grid.


A sidebar tree node whose children are all daily notes of one month now renders as a 7-column calendar grid instead of a vertical list of dated leaves. Closes #700. Filled cells link to that day's note; missing days appear as muted dots — so a
Daily/2026/04/folder showing2026-04-21 … 2026-04-30becomes a glance-able calendar of April 2026 in the sidebar.The implementation reuses the post-render-widget pattern that landed for the timeline heatmap in #699: a Heist splice annotates leaves with semantic data attributes, and a small JS module reads the DOM and swaps the children for a calendar in place. Date detection lives in Haskell exclusively — the JS never reimplements
YYYY-MM-DDparsing, so the timeline-heatmap regex and the sidebar widget stay in lockstep through one source of truth.How it fits together
The two pieces of the seam —
data-iso-dateon each leaf anchor and<div class="emanote-tree-children">around each expanded subtree — are both emitted byrouteTreeSplicesand documented as the contract widgets attach to. Cell primitives (palette, sizes, filled/empty builders, header formatter) moved out oftimeline-heatmap.jsinto a shared@emanote/calendar-gridmodule, so a Tailwind palette refresh edits one file and the two widgets can't drift on user-facing copy.What you get
Apr 2026header, leading blanks so day 1 lands in the right column, and clickable cells for every daily note that existsclassifyMonthGroupreturns null on any subtree containing a non-daily child, so subfolders, regular notes, and mixed-month folders keep their linear listRefinements during review
Eight commits on the branch, six of them follow-ups from
/hickey,/lowy, and/code-policerunning on the actual diff:isAlreadyRendered, kept the DOM as truthdata-iso-dateemanote-dom-over-mirror)isAlreadyRenderedqueries the live DOMTemplate.hswas combining two qualifiers under oneCalendaraliasCalendar.hsnow re-exportsparseRouteDay; single facade'YYYY-MM-DD — title'formatter copied between widgetsformatCellHeaderintocalendar-grid.js> .flex); per-cell<span>wrapper was layout-only:scope a[data-iso-date]; row height moved to grid viaauto-rows-[1rem] place-items-centerCoverage
Four cucumber + Playwright scenarios under
tests/features/smoke.featurecover the user-visible paths: month folder renders as calendar with the right day → note links; the linear list is replaced rather than augmented (no plain2026-04-XXanchors leak through); non-month folders never mount a calendar; the calendar survives morph navigation between same-month dailies (@morph-tagged so static mode skips it). Fixture undertests/fixtures/notebook/calendar-test/2026/04/carries three sparse daily notes (1, 15, 30) so both filled and empty cells get exercised. All 39 e2e scenarios pass in live mode locally; CI runs static + morph variants.Try it locally
Then expand
Daily / 2025 / 03in the sidebar.Generated by
/doon Claude Code (modelclaude-opus-4-7).